HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/api.php
<?php
// includes/api.php
if (!defined('ABSPATH')) exit;

if (!defined('ALLSPICE_SETTINGS_OPTION')) define('ALLSPICE_SETTINGS_OPTION', 'allspice_settings');

function allspice_opt_get(): array {
    $v = get_option(ALLSPICE_SETTINGS_OPTION);
    if (!is_array($v)) $v = array();
    if (!isset($v['partner_id'])) $v['partner_id'] = '';
    if (!isset($v['domain_id'])) $v['domain_id'] = '';
    if (!isset($v['webhook_token'])) $v['webhook_token'] = '';
    if (!isset($v['buttons_cache'])) $v['buttons_cache'] = '';
    if (!isset($v['buttons_count'])) $v['buttons_count'] = 0;
    if (!isset($v['cache_fetched_at'])) $v['cache_fetched_at'] = 0;
    if (!isset($v['cache_last_error'])) $v['cache_last_error'] = '';
    if (!isset($v['cache_ttl'])) $v['cache_ttl'] = 12 * HOUR_IN_SECONDS;
    if (!isset($v['manual_refreshed_at'])) $v['manual_refreshed_at'] = 0;
    if (!isset($v['manual_last_error'])) $v['manual_last_error'] = '';
    if (!isset($v['recipes_count'])) $v['recipes_count'] = 0;
    if (!isset($v['recipes_synced_at'])) $v['recipes_synced_at'] = 0;
    if (!isset($v['recipes_last_error'])) $v['recipes_last_error'] = '';
    if (!isset($v['theme_policies_cache'])) $v['theme_policies_cache'] = '';
    if (!isset($v['theme_policies_synced_at'])) $v['theme_policies_synced_at'] = 0;
    if (!isset($v['theme_policies_last_error'])) $v['theme_policies_last_error'] = '';
    if (!isset($v['content_gates_synced_at'])) $v['content_gates_synced_at'] = 0;
    if (!isset($v['content_gates_last_error'])) $v['content_gates_last_error'] = '';
    if (!isset($v['premium'])) $v['premium'] = true;
    if (!isset($v['premium_checked_at'])) $v['premium_checked_at'] = 0;
    if (!isset($v['premium_last_error'])) $v['premium_last_error'] = '';
    if (!isset($v['widget_variant'])) { $v['widget_variant'] = 'standard'; }
    /*
     * Adaptive rail appearance. Only consulted when widget_variant === 'float'; the standard variant keeps the legacy floating button + WIDCON-d29x rail and is untouched by all of these.
     */
    if (!isset($v['float_desktop_style'])) { $v['float_desktop_style'] = 'floating'; }
    if (!isset($v['float_inline_bg'])) { $v['float_inline_bg'] = '#ffffff'; }
    if (!isset($v['float_inline_border_color'])) { $v['float_inline_border_color'] = '#e7e8ee'; }
    if (!isset($v['float_inline_border_width'])) { $v['float_inline_border_width'] = '1'; }
    if (!isset($v['float_inline_radius'])) { $v['float_inline_radius'] = '16'; }
    if (!isset($v['float_inline_pad_y'])) { $v['float_inline_pad_y'] = '14'; }
    if (!isset($v['float_mobile_style'])) { $v['float_mobile_style'] = 'floating'; }
    if (!isset($v['float_launcher_side'])) { $v['float_launcher_side'] = 'right'; }
    if (!isset($v['float_rail_anchor'])) { $v['float_rail_anchor'] = 'middle'; }
    if (!isset($v['float_rail_size'])) { $v['float_rail_size'] = 'default'; }
    if (!isset($v['float_rail_mode'])) { $v['float_rail_mode'] = 'light'; }
    if (!isset($v['float_rail_accent_top'])) { $v['float_rail_accent_top'] = '#3d7dd6'; }
    if (!isset($v['float_rail_accent_ingredients'])) { $v['float_rail_accent_ingredients'] = '#1fa463'; }
    if (!isset($v['float_rail_accent_instructions'])) { $v['float_rail_accent_instructions'] = '#e9781a'; }
    if (!isset($v['float_rail_accent_search'])) { $v['float_rail_accent_search'] = '#0284c7'; }
    if (!isset($v['float_rail_accent_chat'])) { $v['float_rail_accent_chat'] = '#6d4dc4'; }
    if (!isset($v['float_rail_announce'])) { $v['float_rail_announce'] = 'zone'; }
    if (!isset($v['float_rail_announce_ms'])) { $v['float_rail_announce_ms'] = 4000; }
    if (!isset($v['float_rail_announce_search'])) { $v['float_rail_announce_search'] = true; }
    if (!isset($v['float_rail_pulse'])) { $v['float_rail_pulse'] = true; }
    if (!isset($v['float_rail_hover_expand'])) { $v['float_rail_hover_expand'] = true; }
    if (!isset($v['float_rail_show_jump'])) { $v['float_rail_show_jump'] = true; }
    return $v;
}

/**
 * Normalized adaptive-rail options, safe to hand straight to the bootstrap config. Values are re-validated on read (not only on save) so a hand-edited or migrated option row can never emit an unexpected value into the page.
 */
function allspice_get_float_launcher_options(): array {
    $s = allspice_opt_get();
    $pick = static function ($value, array $allowed, string $fallback): string {
        $value = sanitize_key((string)$value);
        return in_array($value, $allowed, true) ? $value : $fallback;
    };
    $hex = static function ($value, string $fallback): string {
        $value = sanitize_hex_color(trim((string)$value));
        return $value ? $value : $fallback;
    };
    $ms = (int)($s['float_rail_announce_ms'] ?? 4000);
    if ($ms < 0) { $ms = 0; }
    if ($ms > 15000) { $ms = 15000; }
    return array(
        'float_launcher_side' => $pick($s['float_launcher_side'] ?? '', ['left', 'right'], 'right'),
        /*
         * Per-device widget style. "floating" = the fixed bottom-corner overlay; "inline" = the same widget mounted in the document flow above the recipe card (never covers ads). The rail launcher serves both; page.js picks the style by viewport at boot.
         */
        'float_desktop_style' => $pick($s['float_desktop_style'] ?? '', ['floating', 'inline'], 'floating'),
        'float_mobile_style' => $pick($s['float_mobile_style'] ?? '', ['floating', 'inline'], 'floating'),
        /* Inline-block chrome, so publishers can blend the in-flow widget into their page. */
        'float_inline_bg' => $hex($s['float_inline_bg'] ?? '', '#ffffff'),
        'float_inline_border_color' => $hex($s['float_inline_border_color'] ?? '', '#e7e8ee'),
        'float_inline_border_width' => $pick($s['float_inline_border_width'] ?? '', ['0', '1', '2'], '1'),
        'float_inline_radius' => $pick($s['float_inline_radius'] ?? '', ['0', '8', '12', '16', '24'], '16'),
        'float_inline_pad_y' => $pick($s['float_inline_pad_y'] ?? '', ['8', '14', '22'], '14'),
        'float_rail_anchor' => $pick($s['float_rail_anchor'] ?? '', ['top', 'middle', 'bottom'], 'middle'),
        'float_rail_size' => $pick($s['float_rail_size'] ?? '', ['default', 'small'], 'default'),
        'float_rail_mode' => $pick($s['float_rail_mode'] ?? '', ['dark', 'light', 'auto'], 'light'),
        /* One accent per rail state, publisher-tunable (defaults = the built-in zone palette). */
        'float_rail_accent_top' => $hex($s['float_rail_accent_top'] ?? '', '#3d7dd6'),
        'float_rail_accent_ingredients' => $hex($s['float_rail_accent_ingredients'] ?? '', '#1fa463'),
        'float_rail_accent_instructions' => $hex($s['float_rail_accent_instructions'] ?? '', '#e9781a'),
        'float_rail_accent_search' => $hex($s['float_rail_accent_search'] ?? '', '#0284c7'),
        'float_rail_accent_chat' => $hex($s['float_rail_accent_chat'] ?? '', '#6d4dc4'),
        'float_rail_announce' => $pick($s['float_rail_announce'] ?? '', ['zone', 'jump', 'never'], 'zone'),
        'float_rail_announce_ms' => $ms,
        'float_rail_announce_search' => !empty($s['float_rail_announce_search']),
        'float_rail_pulse' => !empty($s['float_rail_pulse']),
        'float_rail_hover_expand' => !empty($s['float_rail_hover_expand']),
        'float_rail_show_jump' => !empty($s['float_rail_show_jump']),
    );
}

function allspice_get_widget_asset_base(): string {
    /* 1.5.01: the widgetfile bundle carries every style (overlay/floating/inline), so the old standard-vs-float split is gone. ALLSPICE_WIDGET_FLOAT_BASE still exists (and points at the same directory) purely so a stale env.php override can't fatal. */
    return ALLSPICE_WIDGET_STANDARD_BASE;
}

/*
 * Per-device widget style: 'standard' | 'floating' | 'inline'. Falls back to the legacy widget_variant (+ float_*_style) options so existing sites keep behaving identically until they touch the new selects.
 */
function allspice_get_widget_style(string $device): string {
    $device = $device === 'mobile' ? 'mobile' : 'desktop';
    $settings = allspice_opt_get();
    $v = sanitize_key((string)($settings["widget_style_{$device}"] ?? ''));
    if (in_array($v, ['standard', 'floating', 'inline'], true)) {
        /* Mobile offers Overlay (standard) or Inline only - a floating card on a phone is near-fullscreen anyway and re-creates the ad-coverage problem. Stored/legacy 'floating' clamps to inline. */
        if ($device === 'mobile' && $v === 'floating') {
            return 'inline';
        }
        return $v;
    }
    if (sanitize_key((string)($settings['widget_variant'] ?? 'standard')) === 'float') {
        if ($device === 'mobile') {
            return 'inline';
        }
        $legacy = sanitize_key((string)($settings['float_desktop_style'] ?? ''));
        return $legacy === 'inline' ? 'inline' : 'floating';
    }
    return 'standard';
}

/* True when NO device uses the standard experience (legacy surfaces can be skipped server-side). */
function allspice_all_devices_float(): bool {
    return allspice_get_widget_style('desktop') !== 'standard'
        && allspice_get_widget_style('mobile') !== 'standard';
}

/*
 * Per-device float-family options, emitted with a _desktop/_mobile suffix. Read order per key:
 * device-suffixed stored value -> legacy un-suffixed stored value -> hard default, so existing
 * configurations migrate silently.
 */
function allspice_get_float_device_options(string $device): array {
    $device = $device === 'mobile' ? 'mobile' : 'desktop';
    $sx = "_{$device}";
    $s = allspice_opt_get();
    $pick = static function ($key, array $allowed, string $fallback) use ($s, $sx) {
        foreach (array("{$key}{$sx}", $key) as $k) {
            if (isset($s[$k])) {
                $v = sanitize_key((string)$s[$k]);
                if (in_array($v, $allowed, true)) { return $v; }
            }
        }
        return $fallback;
    };
    $hex = static function ($key, string $fallback) use ($s, $sx) {
        foreach (array("{$key}{$sx}", $key) as $k) {
            if (isset($s[$k])) {
                $v = sanitize_hex_color((string)$s[$k]);
                if ($v) { return $v; }
            }
        }
        return $fallback;
    };
    $flag = static function ($key, bool $fallback) use ($s, $sx) {
        foreach (array("{$key}{$sx}", $key) as $k) {
            if (isset($s[$k])) { return !empty($s[$k]); }
        }
        return $fallback;
    };
    $int = static function ($key, int $fallback, int $min, int $max) use ($s, $sx) {
        foreach (array("{$key}{$sx}", $key) as $k) {
            if (isset($s[$k]) && is_numeric($s[$k])) {
                return max($min, min($max, (int)$s[$k]));
            }
        }
        return $fallback;
    };
    return array(
        "widget_style{$sx}" => allspice_get_widget_style($device),
        "float_auto_open{$sx}" => $flag('float_auto_open', false),
        "float_auto_open_delay{$sx}" => $int('float_auto_open_delay', 30, 3, 600),
        "search_open_target{$sx}" => $pick('search_open_target', array('full', 'widget', 'advanced_search'), 'full'),
        "float_launcher_side{$sx}" => $pick('float_launcher_side', array('left', 'right'), 'right'),
        "float_rail_anchor{$sx}" => $pick('float_rail_anchor', array('top', 'middle', 'bottom'), 'middle'),
        "float_rail_size{$sx}" => $pick('float_rail_size', array('default', 'small'), 'default'),
        "float_rail_mode{$sx}" => $pick('float_rail_mode', array('dark', 'light', 'auto'), 'light'),
        "float_rail_accent_top{$sx}" => $hex('float_rail_accent_top', '#3d7dd6'),
        "float_rail_accent_ingredients{$sx}" => $hex('float_rail_accent_ingredients', '#1fa463'),
        "float_rail_accent_instructions{$sx}" => $hex('float_rail_accent_instructions', '#e9781a'),
        "float_rail_accent_search{$sx}" => $hex('float_rail_accent_search', '#0284c7'),
        "float_rail_accent_chat{$sx}" => $hex('float_rail_accent_chat', '#6d4dc4'),
        "float_rail_announce{$sx}" => $pick('float_rail_announce', array('zone', 'jump', 'never'), 'zone'),
        "float_rail_announce_ms{$sx}" => $int('float_rail_announce_ms', 4000, 0, 15000),
        "float_rail_announce_search{$sx}" => $flag('float_rail_announce_search', true),
        "float_rail_pulse{$sx}" => $flag('float_rail_pulse', true),
        "float_rail_hover_expand{$sx}" => $flag('float_rail_hover_expand', true),
        "float_rail_show_jump{$sx}" => $flag('float_rail_show_jump', true),
        "float_inline_bg{$sx}" => $hex('float_inline_bg', '#ffffff'),
        "float_inline_border_color{$sx}" => $hex('float_inline_border_color', '#e7e8ee'),
        "float_inline_border_width{$sx}" => $pick('float_inline_border_width', array('0', '1', '2'), '1'),
        "float_inline_radius{$sx}" => $pick('float_inline_radius', array('0', '8', '12', '16', '24'), '16'),
        "float_inline_pad_y{$sx}" => $pick('float_inline_pad_y', array('8', '14', '22'), '14'),
        /* Outer spacing above/below the inline block, replacing the pad_y preset in the UI.
           FALLBACK CHAIN: explicit margin key -> the legacy pad_y preset -> 14, so a site
           that only ever saved pad_y keeps identical spacing, and version skew between the
           plugin and the channel-served page JS is harmless in both directions. */
        "float_inline_margin_top{$sx}" => $int('float_inline_margin_top', (int)$pick('float_inline_pad_y', array('8', '14', '22'), '14'), 0, 60),
        "float_inline_margin_bottom{$sx}" => $int('float_inline_margin_bottom', (int)$pick('float_inline_pad_y', array('8', '14', '22'), '14'), 0, 60),
        /* The minimized inline "Ask Pico" bar above the recipe card. Shown for EVERY style
           (tapping opens whatever experience this device is configured for); publishers can
           toggle it off per device. Default on - and FORCED on for the Inline style, where the
           bar IS the placement's entry point: a stored "off" (e.g. saved before switching the
           style to Inline) would otherwise render nothing at all. The settings page hides the
           checkbox for Inline for the same reason. */
        "float_inline_cta{$sx}" => allspice_get_widget_style($device) === 'inline'
            ? true
            : $flag('float_inline_cta', true),
    );
}

function allspice_get_widget_variant(): string {
    /* Derived: the float architecture (asset base, cfg merge) activates when ANY device uses a
       float-family style. Both-standard sites keep the legacy path byte-identically. */
    return (allspice_get_widget_style('desktop') !== 'standard'
        || allspice_get_widget_style('mobile') !== 'standard') ? 'float' : 'standard';
}

function allspice_is_float_variant(): bool {
    return allspice_get_widget_variant() === 'float';
}

function allspice_opt_update(array $patch): bool {
    $cur = allspice_opt_get();
    $new = array_merge($cur, $patch);
    $ok = update_option(ALLSPICE_SETTINGS_OPTION, $new, false);
    wp_cache_delete(ALLSPICE_SETTINGS_OPTION, 'options');
    wp_cache_delete('alloptions', 'options');
    return (bool)$ok;
}

function allspice_opt_update_force(array $patch): bool {
    $cur = allspice_opt_get();
    $new = array_merge($cur, $patch);
    delete_option(ALLSPICE_SETTINGS_OPTION);
    $ok = add_option(ALLSPICE_SETTINGS_OPTION, $new, '', false);
    wp_cache_delete(ALLSPICE_SETTINGS_OPTION, 'options');
    wp_cache_delete('alloptions', 'options');
    return (bool)$ok;
}

function allspice_webhook_base(): string {
    $base = defined('ALLSPICE_WEBHOOK_BASE') ? (string)ALLSPICE_WEBHOOK_BASE : ALLSPICE_WEBHOOK_BASE_DEFAULT;
    $base = rtrim($base, '/');
    return apply_filters('allspice_webhook_base', $base);
}

function allspice_buttons_endpoint(): string {
    $path = '/loadButtons';
    return apply_filters('allspice_buttons_endpoint', allspice_webhook_base() . $path);
}

function allspice_wp_event_endpoint(): string {
    $path = '/wp';
    return apply_filters('allspice_wp_event_endpoint', allspice_webhook_base() . $path);
}

function allspice_buttons_cache_count_from_payload($json): int {
    if (is_array($json) && isset($json['buttons']) && is_array($json['buttons'])) return count($json['buttons']);
    if (is_array($json) && array_is_list($json)) return count($json);
    return 0;
}

function allspice_buttons_cache_get(): array {
    $s = allspice_opt_get();
    $raw = (string)$s['buttons_cache'];
    if ($raw === '') return array();
    $json = json_decode($raw, true);
    if (!is_array($json)) return array();
    if (isset($json['buttons']) && is_array($json['buttons'])) return $json['buttons'];
    if (isset($json[0]) && is_array($json)) return $json;
    return array();
}

function allspice_refresh_buttons_cache(bool $force = false): array {
    $s = allspice_opt_get();
    $partner_id = trim((string)$s['partner_id']);
    $domain_id = trim((string)$s['domain_id']);
    $token = trim((string)$s['webhook_token']);
    $ttl = (int)$s['cache_ttl'];
    if ($ttl < 300) $ttl = 300;
    $now = time();
    if (!$force && (int)$s['cache_fetched_at'] > 0 && ($now - (int)$s['cache_fetched_at']) < $ttl && (string)$s['buttons_cache'] !== '') {
        return array('ok' => true, 'cached' => true, 'count' => (int)$s['buttons_count'], 'error' => '', 'wrote' => false);
    }
    if ($partner_id === '' || $domain_id === '' || $token === '') {
        $msg = 'Missing partner_id, domain_id, or webhook_token';
        $wrote = allspice_opt_update(array('cache_last_error' => $msg));
        return array('ok' => $wrote, 'cached' => false, 'count' => 0, 'error' => $msg, 'wrote' => $wrote);
    }
    $endpoint = allspice_buttons_endpoint();
    $url = add_query_arg(array('partner_id' => $partner_id, 'domain_id' => $domain_id), $endpoint);
    $headers = array(
        'Accept' => 'application/json',
        'Authorization' => 'Bearer ' . $token,
        'X-Allspice-Partner-Id' => $partner_id,
        'X-Allspice-Domain-Id' => $domain_id,
        'Cache-Control' => 'no-cache',
        'Pragma' => 'no-cache',
    );
    $resp = wp_remote_get($url, array('timeout' => 15, 'headers' => $headers));
    if (is_wp_error($resp)) {
        $msg = trim((string)$resp->get_error_message());
        if ($msg === '') {
            $codes = $resp->get_error_codes();
            $msg = !empty($codes)
                ? ('WP_Error: ' . implode(', ', $codes))
                : 'WP_Error with no message (wp_remote_get)';
        }
        $wrote = allspice_opt_update(array('cache_last_error' => $msg));
        return array('ok' => $wrote, 'cached' => false, 'count' => 0, 'error' => $msg, 'wrote' => $wrote);
    }
    $code = (int)wp_remote_retrieve_response_code($resp);
    $body = (string)wp_remote_retrieve_body($resp);
    if ($code < 200 || $code >= 300) {
        $msg = 'HTTP ' . $code . ': ' . substr($body, 0, 300);
        $wrote = allspice_opt_update(array('cache_last_error' => $msg));
        return array('ok' => $wrote, 'cached' => false, 'count' => 0, 'error' => $msg, 'wrote' => $wrote);
    }
    $json = json_decode($body, true);
    if (!is_array($json)) {
        $msg = 'Invalid JSON: ' . substr($body, 0, 300);
        $wrote = allspice_opt_update(array('cache_last_error' => $msg));
        return array('ok' => $wrote, 'cached' => false, 'count' => 0, 'error' => $msg, 'wrote' => $wrote);
    }
    $count = allspice_buttons_cache_count_from_payload($json);
    $wrote = allspice_opt_update(array(
        'buttons_cache' => wp_json_encode($json),
        'buttons_count' => $count,
        'cache_fetched_at' => $now,
        'cache_last_error' => '',
        'cache_ttl' => $ttl,
        'allspice_debug_nonce' => wp_generate_uuid4(),
    ));
    $s2 = allspice_opt_get();
//    error_log('[Allspice] refresh finished. now=' . $now . ' stored_fetched_at=' . (int)$s2['cache_fetched_at']);
    return array('ok' => true, 'cached' => false, 'count' => $count, 'error' => $wrote ? '' : 'Fetched OK, but option write failed (update_option returned false)', 'wrote' => $wrote);
}
        
function allspice_recipes_endpoint(): string {
    $path = '/loadRecipesForWPSync';
    return apply_filters('allspice_recipes_endpoint', allspice_webhook_base() . $path);
}
        
function allspice_recipes_list_from_payload($json): array {
    if (is_array($json) && isset($json['recipes']) && is_array($json['recipes'])) return $json['recipes'];
    if (is_array($json) && isset($json['items']) && is_array($json['items'])) return $json['items'];
    if (is_array($json) && isset($json[0]) && is_array($json)) return $json;
    return array();
}
        
function allspice_refresh_recipes_cache(bool $force = false): array {
    $s = allspice_opt_get();
    $partner_id = trim((string)$s['partner_id']);
    $domain_id = trim((string)$s['domain_id']);
    $token = trim((string)$s['webhook_token']);
    $now = time();
    if ($partner_id === '' || $domain_id === '' || $token === '') {
        $msg = 'Missing partner_id, domain_id, or webhook_token';
        allspice_opt_update(array('recipes_last_error' => $msg));
        return array('ok' => false, 'count' => 0, 'error' => $msg);
    }
    $endpoint = allspice_recipes_endpoint();
    $url = add_query_arg(array('partner_id' => $partner_id, 'domain_id' => $domain_id), $endpoint);
    $headers = array(
        'Accept' => 'application/json',
        'Authorization' => 'Bearer ' . $token,
        'X-Allspice-Partner-Id' => $partner_id,
        'X-Allspice-Domain-Id' => $domain_id,
        'Cache-Control' => 'no-cache',
        'Pragma' => 'no-cache',
    );
    $resp = wp_remote_get($url, array('timeout' => 30, 'headers' => $headers));
    if (is_wp_error($resp)) {
        $msg = trim((string)$resp->get_error_message());
        if ($msg === '') {
            $codes = $resp->get_error_codes();
            $msg = !empty($codes)
                ? ('WP_Error: ' . implode(', ', $codes))
                : 'WP_Error with no message (wp_remote_get)';
        }
        allspice_opt_update(array('recipes_last_error' => $msg));
        return array('ok' => false, 'count' => 0, 'error' => $msg);
    }
    $code = (int)wp_remote_retrieve_response_code($resp);
    $body = (string)wp_remote_retrieve_body($resp);
    if ($code < 200 || $code >= 300) {
        $msg = 'HTTP ' . $code . ': ' . substr($body, 0, 300);
        allspice_opt_update(array('recipes_last_error' => $msg));
        return array('ok' => false, 'count' => 0, 'error' => $msg);
    }
    $json = json_decode($body, true);
    if (!is_array($json)) {
        $msg = 'Invalid JSON: ' . substr($body, 0, 300);
        allspice_opt_update(array('recipes_last_error' => $msg));
        return array('ok' => false, 'count' => 0, 'error' => $msg);
    }
    $premium = array_key_exists('premium', $json) ? (bool)$json['premium'] : true;
    $items = allspice_recipes_list_from_payload($json);
    if (!function_exists('allspice_upsert_recipes') || !function_exists('allspice_recipes_count')) {
        $msg = 'Recipe DB helpers not loaded (allspice_upsert_recipes / allspice_recipes_count missing)';
        allspice_opt_update(array('recipes_last_error' => $msg));
        return array('ok' => false, 'count' => 0, 'error' => $msg);
    }
    if (function_exists('allspice_recipes_table_ensure_columns')) {
        allspice_recipes_table_ensure_columns();
    }
    $written = allspice_upsert_recipes($items);
    $count = allspice_recipes_count();
    allspice_opt_update(array(
        'recipes_count' => $count,
        'recipes_synced_at' => $now,
        'recipes_last_error' => '',
        'premium' => $premium,
        'premium_checked_at' => $now,
        'premium_last_error' => '',
    ));
    return array(
        'ok' => true,
        'count' => $count,
        'error' => '',
        'written' => $written,
        'premium' => $premium,
    );
}

function allspice_theme_policies_endpoint(): string {
    $path = '/loadThemeAndPolicies';
    return apply_filters('allspice_theme_policies_endpoint', allspice_webhook_base() . $path);
}
        
/*
 * Memberships block from /loadThemeAndPolicies: a top-level dictionary, populated per domain
 * by the webhook service ({} for domains without a program). PUBLIC configuration - no secrets
 * or tokens live in it. Normalized to an associative array; absent/null/invalid (including a
 * sequential list, which the contract never produces) becomes []. Consumers must treat [] as
 * "no memberships" - this change ships no gates, cookies, or UI.
 */
function allspice_memberships_normalize($value): array {
    if (!is_array($value)) return [];
    if ($value === []) return [];
    /* PHP 7.4-safe list check (no array_is_list): a valid memberships value is a dictionary. */
    if (array_keys($value) === range(0, count($value) - 1)) return [];
    return $value;
}

function allspice_theme_policies_extract($json): array {
    if (!is_array($json)) return ['theme' => null, 'policy' => null, 'domain' => '', 'memberships' => []];
    if (array_key_exists('theme', $json) || array_key_exists('policy', $json) || array_key_exists('domain', $json) || array_key_exists('domain_name', $json)) {
        return [
            'theme'  => $json['theme']  ?? null,
            'policy' => $json['policy'] ?? null,
            'domain' => (string)($json['domain'] ?? $json['domain_name'] ?? ''),
            'memberships' => allspice_memberships_normalize($json['memberships'] ?? null),
        ];
    }
    if (isset($json['data']) && is_array($json['data']) && (isset($json['data']['theme']) || isset($json['data']['policy']) || isset($json['data']['domain']) || isset($json['data']['domain_name']))) {
        return [
            'theme'  => $json['data']['theme']  ?? null,
            'policy' => $json['data']['policy'] ?? null,
            'domain' => (string)($json['data']['domain'] ?? $json['data']['domain_name'] ?? ''),
            'memberships' => allspice_memberships_normalize($json['data']['memberships'] ?? null),
        ];
    }
    foreach ($json as $v) {
        if (!is_array($v)) continue;
        if (isset($v['theme']) || isset($v['policy']) || isset($v['domain']) || isset($v['domain_name'])) {
            return [
                'theme'  => $v['theme']  ?? null,
                'policy' => $v['policy'] ?? null,
                'domain' => (string)($v['domain'] ?? $v['domain_name'] ?? ''),
                'memberships' => allspice_memberships_normalize($v['memberships'] ?? null),
            ];
        }
    }
    return ['theme' => null, 'policy' => null, 'domain' => '', 'memberships' => []];
}
        
function allspice_theme_policies_cache_get(): array {
    $s = allspice_opt_get();
    $raw = (string)($s['theme_policies_cache'] ?? '');
    if ($raw === '') return ['theme' => null, 'policy' => null, 'domain' => '', 'memberships' => [], 'raw' => null];
    $json = json_decode($raw, true);
    if (!is_array($json)) return ['theme' => null, 'policy' => null, 'domain' => '', 'memberships' => [], 'raw' => null];
    $ex = allspice_theme_policies_extract($json);
    return [
        'theme' => $ex['theme'],
        'policy' => $ex['policy'],
        'domain' => (string)($ex['domain'] ?? ''),
        'memberships' => is_array($ex['memberships'] ?? null) ? $ex['memberships'] : [],
        'raw' => $json
    ];
}

function allspice_refresh_theme_policies_cache(bool $force = false): array {
    $s = allspice_opt_get();
    $partner_id = trim((string)$s['partner_id']);
    $domain_id  = trim((string)$s['domain_id']);
    $token = trim((string)$s['webhook_token']);
    $ttl = (int)($s['cache_ttl'] ?? 12 * HOUR_IN_SECONDS);
    if ($ttl < 300) $ttl = 300;
    $now = time();
    if (
        !$force &&
        (int)($s['theme_policies_synced_at'] ?? 0) > 0 &&
        ($now - (int)($s['theme_policies_synced_at'] ?? 0)) < $ttl &&
        (string)($s['theme_policies_cache'] ?? '') !== ''
    ) {
        $cached = allspice_theme_policies_cache_get();
        $theme_ok = ($cached['theme'] !== null);
        $pol_ok = ($cached['policy'] !== null);
        $domain_ok = (trim((string)($cached['domain'] ?? '')) !== '');
        return [
            'ok' => true,
            'cached' => true,
            'theme_ok' => $theme_ok,
            'policy_ok' => $pol_ok,
            'domain_ok' => $domain_ok,
            'error' => '',
            'wrote' => false,
        ];
    }
    if ($partner_id === '' || $domain_id === '' || $token === '') {
        $msg = 'Missing partner_id, domain_id, or webhook_token';
        $wrote = allspice_opt_update(['theme_policies_last_error' => $msg]);
        return ['ok' => $wrote, 'cached' => false, 'theme_ok' => false, 'policy_ok' => false, 'domain_ok' => false, 'error' => $msg, 'wrote' => $wrote];
    }
    $endpoint = allspice_theme_policies_endpoint();
    $url = add_query_arg(['partner_id' => $partner_id, 'domain_id' => $domain_id], $endpoint);
    $headers = [
        'Accept' => 'application/json',
        'Authorization' => 'Bearer ' . $token,
        'X-Allspice-Partner-Id' => $partner_id,
        'X-Allspice-Domain-Id' => $domain_id,
        'Cache-Control' => 'no-cache',
        'Pragma' => 'no-cache',
    ];
    $resp = wp_remote_get($url, ['timeout' => 15, 'headers' => $headers]);
    if (is_wp_error($resp)) {
        $msg = trim((string)$resp->get_error_message());
        if ($msg === '') {
            $codes = $resp->get_error_codes();
            $msg = !empty($codes) ? ('WP_Error: ' . implode(', ', $codes)) : 'WP_Error with no message (wp_remote_get)';
        }
        $wrote = allspice_opt_update(['theme_policies_last_error' => $msg]);
        return ['ok' => $wrote, 'cached' => false, 'theme_ok' => false, 'policy_ok' => false, 'domain_ok' => false, 'error' => $msg, 'wrote' => $wrote];
    }
    $code = (int)wp_remote_retrieve_response_code($resp);
    $body = (string)wp_remote_retrieve_body($resp);
    if ($code < 200 || $code >= 300) {
        $msg = 'HTTP ' . $code . ': ' . substr($body, 0, 300);
        $wrote = allspice_opt_update(['theme_policies_last_error' => $msg]);
        return ['ok' => $wrote, 'cached' => false, 'theme_ok' => false, 'policy_ok' => false, 'domain_ok' => false, 'error' => $msg, 'wrote' => $wrote];
    }
    $json = json_decode($body, true);
    if (!is_array($json)) {
        $msg = 'Invalid JSON: ' . substr($body, 0, 300);
        $wrote = allspice_opt_update(['theme_policies_last_error' => $msg]);
        return ['ok' => $wrote, 'cached' => false, 'theme_ok' => false, 'policy_ok' => false, 'domain_ok' => false, 'error' => $msg, 'wrote' => $wrote];
    }
    $ex = allspice_theme_policies_extract($json);
    $theme_ok = ($ex['theme'] !== null);
    $pol_ok = ($ex['policy'] !== null);
    $domain_ok = (trim((string)($ex['domain'] ?? '')) !== '');
    /* Debug-safe: shape only (presence + top-level keys), never the values - memberships is
       public config, but logging habits should not assume that stays true. */
    if (function_exists('allspice_console_log')) {
        $m = $ex['memberships'] ?? [];
        allspice_console_log('[Allspice] theme/policy sync: memberships', [
            'present' => $m !== [],
            'keys' => is_array($m) ? array_keys($m) : [],
        ]);
    }
    $wrote = allspice_opt_update([
        'theme_policies_cache' => wp_json_encode($json),
        'theme_policies_synced_at' => $now,
        'theme_policies_last_error' => '',
    ]);
    return [
        'ok' => true,
        'cached' => false,
        'theme_ok' => $theme_ok,
        'policy_ok' => $pol_ok,
        'domain_ok' => $domain_ok,
        'error' => $wrote ? '' : 'Fetched OK, but option write failed (update_option returned false)',
        'wrote' => $wrote,
    ];
}
        
function allspice_theme_policy_payload(): array {
    $s = allspice_opt_get();
    $tp = function_exists('allspice_theme_policies_cache_get')
        ? allspice_theme_policies_cache_get()
        : ['theme' => null, 'policy' => null, 'domain' => '', 'memberships' => [], 'raw' => null];
    $raw = is_array($tp['raw']) ? $tp['raw'] : [];
    return [
        'partner_id' => (string)$s['partner_id'],
        'domain_id' => (string)$s['domain_id'],
        'synced_at' => (int)($s['theme_policies_synced_at'] ?? 0),
        'last_error' => (string)($s['theme_policies_last_error'] ?? ''),
        'ok' => isset($raw['ok']) ? (bool)$raw['ok'] : null,
        'hash' => (string)($raw['hash'] ?? ''),
        'fetched_at' => (string)($raw['fetched_at'] ?? ''),
        'theme_id' => (string)($raw['theme_id'] ?? ''),
        /* Publisher ecosystem (GCIP tenant) id, synced from the domain document via
           loadThemeAndPolicies - never entered on the settings page. Empty = default pool.
           Display/routing only: widget AUTH derives its own value from the auth bridge and
           the token claim, and ignores this field. */
        'ecosystem_id' => (string)($raw['ecosystem_id'] ?? ''),
        'domain' => (string)($tp['domain'] ?? ''),
        'theme' => $tp['theme'],
        'policy' => $tp['policy'],
        /* Memberships config from /loadThemeAndPolicies (public configuration - no secrets).
           Cast so the browser ALWAYS sees an object: an empty PHP array would JSON-encode as
           [] and consumers checking `memberships.enabled` would face two shapes. {} = this
           domain has no memberships program (every domain today except the test one). */
        'memberships' => (is_array($tp['memberships'] ?? null) && $tp['memberships'] !== [])
            ? $tp['memberships']
            : (object)[],
    ];
}